[id].vue 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623
  1. <template>
  2. <div v-if="workflow" class="flex h-screen">
  3. <div
  4. v-if="state.showSidebar"
  5. class="w-80 bg-white dark:bg-gray-800 py-6 relative border-l border-gray-100 dark:border-gray-700 dark:border-opacity-50 flex flex-col"
  6. >
  7. <workflow-edit-block
  8. v-if="editState.editing"
  9. v-model:autocomplete="autocompleteState.cache"
  10. :data="editState.blockData"
  11. :data-changed="autocompleteState.dataChanged"
  12. :workflow="workflow"
  13. :editor="editor"
  14. @update="updateBlockData"
  15. @close="(editState.editing = false), (editState.blockData = {})"
  16. />
  17. <workflow-details-card
  18. v-else
  19. :workflow="workflow"
  20. @update="updateWorkflow"
  21. />
  22. </div>
  23. <div class="flex-1 relative overflow-auto">
  24. <div
  25. class="absolute w-full flex items-center z-10 left-0 p-4 top-0 pointer-events-none"
  26. >
  27. <ui-tabs
  28. v-model="state.activeTab"
  29. class="border-none px-2 rounded-lg h-full space-x-1 bg-white dark:bg-gray-800 pointer-events-auto"
  30. >
  31. <button
  32. v-tooltip="
  33. `${t('workflow.toggleSidebar')} (${
  34. shortcut['editor:toggle-sidebar'].readable
  35. })`
  36. "
  37. style="margin-right: 6px"
  38. @click="toggleSidebar"
  39. >
  40. <v-remixicon
  41. :name="state.showSidebar ? 'riSideBarFill' : 'riSideBarLine'"
  42. />
  43. </button>
  44. <ui-tab value="editor">{{ t('common.editor') }}</ui-tab>
  45. <ui-tab value="logs" class="flex items-center">
  46. {{ t('common.log', 2) }}
  47. <span
  48. v-if="workflowStore.states.length > 0"
  49. class="ml-2 p-1 text-center inline-block text-xs rounded-full bg-accent text-white dark:text-black"
  50. style="min-width: 25px"
  51. >
  52. {{ workflowStore.states.length }}
  53. </span>
  54. </ui-tab>
  55. </ui-tabs>
  56. <div class="flex-grow pointer-events-none" />
  57. <editor-local-actions
  58. :editor="editor"
  59. :workflow="workflow"
  60. :is-data-changed="state.dataChanged"
  61. @update="onActionUpdated"
  62. @modal="(modalState.name = $event), (modalState.show = true)"
  63. />
  64. </div>
  65. <ui-tab-panels
  66. v-model="state.activeTab"
  67. class="overflow-hidden h-full w-full"
  68. @drop="onDropInEditor"
  69. @dragend="clearHighlightedElements"
  70. @dragover.prevent="onDragoverEditor"
  71. >
  72. <ui-tab-panel cache value="editor" class="w-full">
  73. <workflow-editor
  74. v-if="state.workflowConverted"
  75. :id="route.params.id"
  76. :data="workflow.drawflow"
  77. class="h-screen"
  78. @init="onEditorInit"
  79. @edit="initEditBlock"
  80. @update:node="state.dataChanged = true"
  81. @delete:node="state.dataChanged = true"
  82. />
  83. <editor-local-ctx-menu
  84. v-if="editor"
  85. :editor="editor"
  86. @copy="copySelectedElements"
  87. @paste="pasteCopiedElements"
  88. @duplicate="duplicateElements"
  89. />
  90. </ui-tab-panel>
  91. <ui-tab-panel value="logs" class="mt-24">
  92. <editor-logs
  93. :workflow-id="route.params.id"
  94. :workflow-states="workflowStore.states"
  95. />
  96. </ui-tab-panel>
  97. </ui-tab-panels>
  98. </div>
  99. </div>
  100. <ui-modal
  101. v-model="modalState.show"
  102. :content-class="activeWorkflowModal?.width || 'max-w-xl'"
  103. v-bind="activeWorkflowModal.attrs || {}"
  104. >
  105. <template v-if="activeWorkflowModal.title" #header>
  106. {{ activeWorkflowModal.title }}
  107. <a
  108. v-if="activeWorkflowModal.docs"
  109. :title="t('common.docs')"
  110. :href="activeWorkflowModal.docs"
  111. target="_blank"
  112. class="inline-block align-middle"
  113. >
  114. <v-remixicon name="riInformationLine" size="20" />
  115. </a>
  116. </template>
  117. <component
  118. :is="activeWorkflowModal.component"
  119. v-bind="{ workflow }"
  120. v-on="activeWorkflowModal?.events || {}"
  121. @update="updateWorkflow"
  122. @close="modalState.show = false"
  123. />
  124. </ui-modal>
  125. </template>
  126. <script setup>
  127. import {
  128. provide,
  129. reactive,
  130. computed,
  131. onMounted,
  132. shallowRef,
  133. onBeforeUnmount,
  134. } from 'vue';
  135. import { useI18n } from 'vue-i18n';
  136. import { useRoute, useRouter, onBeforeRouteLeave } from 'vue-router';
  137. import { customAlphabet } from 'nanoid';
  138. import { useStore } from '@/stores/main';
  139. import { useUserStore } from '@/stores/user';
  140. import { useWorkflowStore } from '@/stores/workflow';
  141. import { useShortcut } from '@/composable/shortcut';
  142. import { tasks } from '@/utils/shared';
  143. import { debounce, parseJSON, throttle } from '@/utils/helper';
  144. import { fetchApi } from '@/utils/api';
  145. import browser from 'webextension-polyfill';
  146. import EditorUtils from '@/utils/EditorUtils';
  147. import convertWorkflowData from '@/utils/convertWorkflowData';
  148. import WorkflowShare from '@/components/newtab/workflow/WorkflowShare.vue';
  149. import WorkflowEditor from '@/components/newtab/workflow/WorkflowEditor.vue';
  150. import WorkflowSettings from '@/components/newtab/workflow/WorkflowSettings.vue';
  151. import WorkflowEditBlock from '@/components/newtab/workflow/WorkflowEditBlock.vue';
  152. import WorkflowDataTable from '@/components/newtab/workflow/WorkflowDataTable.vue';
  153. import WorkflowGlobalData from '@/components/newtab/workflow/WorkflowGlobalData.vue';
  154. import WorkflowDetailsCard from '@/components/newtab/workflow/WorkflowDetailsCard.vue';
  155. import EditorLogs from '@/components/newtab/workflow/editor/EditorLogs.vue';
  156. import EditorLocalCtxMenu from '@/components/newtab/workflow/editor/EditorLocalCtxMenu.vue';
  157. import EditorLocalActions from '@/components/newtab/workflow/editor/EditorLocalActions.vue';
  158. const nanoid = customAlphabet('1234567890abcdef', 7);
  159. const { t } = useI18n();
  160. const store = useStore();
  161. const route = useRoute();
  162. const router = useRouter();
  163. const userStore = useUserStore();
  164. const workflowStore = useWorkflowStore();
  165. /* eslint-disable-next-line */
  166. const shortcut = useShortcut('editor:toggle-sidebar', toggleSidebar);
  167. const editor = shallowRef(null);
  168. const state = reactive({
  169. showSidebar: true,
  170. dataChanged: false,
  171. workflowConverted: false,
  172. activeTab: route.query.tab || 'editor',
  173. });
  174. const modalState = reactive({
  175. name: '',
  176. show: false,
  177. });
  178. const editState = reactive({
  179. blockData: {},
  180. editing: false,
  181. });
  182. const autocompleteState = reactive({
  183. cache: new Map(),
  184. dataChanged: false,
  185. });
  186. const workflowPayload = {
  187. data: {},
  188. isUpdating: false,
  189. };
  190. const workflowModals = {
  191. table: {
  192. icon: 'riKey2Line',
  193. width: 'max-w-2xl',
  194. component: WorkflowDataTable,
  195. title: t('workflow.table.title'),
  196. docs: 'https://docs.automa.site/api-reference/table.html',
  197. },
  198. 'workflow-share': {
  199. icon: 'riShareLine',
  200. component: WorkflowShare,
  201. attrs: {
  202. blur: true,
  203. persist: true,
  204. customContent: true,
  205. },
  206. events: {
  207. close() {
  208. modalState.show = false;
  209. modalState.name = '';
  210. },
  211. publish() {
  212. modalState.show = false;
  213. modalState.name = '';
  214. },
  215. },
  216. },
  217. 'global-data': {
  218. width: 'max-w-2xl',
  219. icon: 'riDatabase2Line',
  220. component: WorkflowGlobalData,
  221. title: t('common.globalData'),
  222. docs: 'https://docs.automa.site/api-reference/global-data.html',
  223. },
  224. settings: {
  225. width: 'max-w-2xl',
  226. icon: 'riSettings3Line',
  227. component: WorkflowSettings,
  228. title: t('common.settings'),
  229. attrs: {
  230. customContent: true,
  231. },
  232. events: {
  233. close() {
  234. modalState.show = false;
  235. modalState.name = '';
  236. },
  237. },
  238. },
  239. };
  240. const workflow = computed(() => workflowStore.getById(route.params.id));
  241. const activeWorkflowModal = computed(
  242. () => workflowModals[modalState.name] || {}
  243. );
  244. provide('workflow', {
  245. editState,
  246. data: workflow,
  247. });
  248. const updateBlockData = debounce((data) => {
  249. const node = editor.value.getNode.value(editState.blockData.blockId);
  250. if (editState.blockData.itemId) {
  251. const itemIndex = node.data.blocks.findIndex(
  252. ({ itemId }) => itemId === editState.blockData.itemId
  253. );
  254. if (itemIndex === -1) return;
  255. node.data.blocks[itemIndex].data = data;
  256. } else {
  257. node.data = data;
  258. }
  259. editState.blockData.data = data;
  260. state.dataChanged = true;
  261. }, 250);
  262. const updateHostedWorkflow = throttle(async () => {
  263. if (!userStore.user || workflowPayload.isUpdating) return;
  264. const isHosted = userStore.hostedWorkflows[route.params.id];
  265. const isBackup = userStore.backupIds.includes(route.params.id);
  266. const workflowExist = workflowStore.getById(route.params.id);
  267. if (
  268. (!isBackup && !isHosted) ||
  269. (workflowExist && Object.keys(workflowPayload.data).length === 0)
  270. )
  271. return;
  272. workflowPayload.isUpdating = true;
  273. const delKeys = [
  274. 'id',
  275. 'pass',
  276. 'logs',
  277. 'trigger',
  278. 'createdAt',
  279. 'isDisabled',
  280. 'isProtected',
  281. ];
  282. delKeys.forEach((key) => {
  283. delete workflowPayload.data[key];
  284. });
  285. try {
  286. if (typeof workflowPayload.data.drawflow === 'string') {
  287. workflowPayload.data.drawflow = parseJSON(
  288. workflowPayload.data.drawflow,
  289. workflowPayload.data.drawflow
  290. );
  291. }
  292. const response = await fetchApi(`/me/workflows/${route.params.id}`, {
  293. method: 'PUT',
  294. keepalive: true,
  295. body: JSON.stringify({
  296. workflow: workflowPayload.data,
  297. }),
  298. });
  299. if (!response.ok) throw new Error(response.message);
  300. if (isBackup) {
  301. const result = await response.json();
  302. if (result.updatedAt) {
  303. await browser.storage.local.set({ lastBackup: result.updatedAt });
  304. }
  305. }
  306. workflowPayload.data = {};
  307. workflowPayload.isUpdating = false;
  308. } catch (error) {
  309. console.error(error);
  310. workflowPayload.isUpdating = false;
  311. }
  312. }, 5000);
  313. const onNodesChange = debounce((changes) => {
  314. changes.forEach(({ type, id }) => {
  315. if (type === 'remove') {
  316. if (editState.blockData.blockId === id) {
  317. editState.editing = false;
  318. editState.blockData = {};
  319. }
  320. }
  321. });
  322. }, 250);
  323. function toggleSidebar() {
  324. state.showSidebar = !state.showSidebar;
  325. localStorage.setItem('workflow:sidebar', state.showSidebar);
  326. }
  327. function initEditBlock(data) {
  328. const { editComponent } = tasks[data.id];
  329. editState.editing = true;
  330. editState.blockData = { ...data, editComponent };
  331. }
  332. function updateWorkflow(data) {
  333. workflowStore.update({
  334. data,
  335. id: route.params.id,
  336. });
  337. workflowPayload.data = { ...workflowPayload.data, ...data };
  338. updateHostedWorkflow();
  339. }
  340. function onActionUpdated({ data, changedIndicator }) {
  341. state.dataChanged = changedIndicator;
  342. workflowPayload.data = { ...workflowPayload.data, ...data };
  343. updateHostedWorkflow();
  344. }
  345. function onEditorInit(instance) {
  346. editor.value = instance;
  347. instance.onEdgesChange((changes) => {
  348. changes.forEach(({ type }) => {
  349. if (state.dataChanged) return;
  350. state.dataChanged = type !== 'select';
  351. });
  352. });
  353. instance.onEdgeDoubleClick(({ edge }) => {
  354. instance.removeEdges([edge]);
  355. });
  356. instance.onNodesChange(onNodesChange);
  357. }
  358. function clearHighlightedElements() {
  359. const elements = document.querySelectorAll(
  360. '.dropable-area__node, .dropable-area__handle'
  361. );
  362. elements.forEach((element) => {
  363. element.classList.remove('dropable-area__node');
  364. element.classList.remove('dropable-area__handle');
  365. });
  366. }
  367. function toggleHighlightElement({ target, elClass, classes }) {
  368. const targetEl = target.closest(elClass);
  369. if (targetEl) {
  370. targetEl.classList.add(classes);
  371. } else {
  372. const elements = document.querySelectorAll(`.${classes}`);
  373. elements.forEach((element) => {
  374. element.classList.remove(classes);
  375. });
  376. }
  377. }
  378. function onDragoverEditor({ target }) {
  379. toggleHighlightElement({
  380. target,
  381. elClass: '.vue-flow__handle.source',
  382. classes: 'dropable-area__handle',
  383. });
  384. if (!target.closest('.vue-flow__handle')) {
  385. toggleHighlightElement({
  386. target,
  387. elClass: '.vue-flow__node:not(.vue-flow__node-BlockGroup)',
  388. classes: 'dropable-area__node',
  389. });
  390. }
  391. }
  392. function onDropInEditor({ dataTransfer, clientX, clientY, target }) {
  393. const block = parseJSON(dataTransfer.getData('block'), null);
  394. if (!block) return;
  395. clearHighlightedElements();
  396. const nodeEl = EditorUtils.isNode(target);
  397. if (nodeEl) {
  398. EditorUtils.replaceNode(editor.value, { block, target: nodeEl });
  399. return;
  400. }
  401. const isTriggerExists =
  402. block.id === 'trigger' &&
  403. editor.value.getNodes.value.some((node) => node.label === 'trigger');
  404. if (isTriggerExists) return;
  405. const position = editor.value.project({ x: clientX - 360, y: clientY - 18 });
  406. const newNode = {
  407. position,
  408. id: nanoid(),
  409. label: block.id,
  410. data: block.data,
  411. type: block.component,
  412. };
  413. editor.value.addNodes([newNode]);
  414. const edgeEl = EditorUtils.isEdge(target);
  415. const handleEl = EditorUtils.isHandle(target);
  416. if (handleEl) {
  417. EditorUtils.appendNode(editor.value, {
  418. target: handleEl,
  419. nodeId: newNode.id,
  420. });
  421. } else if (edgeEl) {
  422. EditorUtils.insertBetweenNode(editor.value, {
  423. target: edgeEl,
  424. nodeId: newNode.id,
  425. outputs: block.outputs,
  426. });
  427. }
  428. if (block.fromGroup) {
  429. setTimeout(() => {
  430. const blockEl = document.querySelector(`[data-id="${newNode.id}"]`);
  431. blockEl?.setAttribute('group-item-id', block.itemId);
  432. }, 200);
  433. }
  434. state.dataChanged = true;
  435. }
  436. function copyElements(nodes, edges, initialPos) {
  437. const newIds = new Map();
  438. let firstNodePos = null;
  439. const newNodes = nodes.map(({ id, label, position, data, type }, index) => {
  440. const newNodeId = nanoid();
  441. const nodePos = {
  442. z: position.z || 0,
  443. y: position.y + 50,
  444. x: position.x + 50,
  445. };
  446. newIds.set(id, newNodeId);
  447. if (initialPos) {
  448. if (index === 0) {
  449. firstNodePos = {
  450. x: nodePos.x,
  451. y: nodePos.y,
  452. };
  453. initialPos = editor.value.project({
  454. y: initialPos.clientY,
  455. x: initialPos.clientX - 360,
  456. });
  457. Object.assign(nodePos, initialPos);
  458. } else {
  459. const xDistance = nodePos.x - firstNodePos.x;
  460. const yDistance = nodePos.y - firstNodePos.y;
  461. nodePos.x = initialPos.x + xDistance;
  462. nodePos.y = initialPos.y + yDistance;
  463. }
  464. }
  465. return {
  466. type,
  467. data,
  468. label,
  469. id: newNodeId,
  470. selected: true,
  471. position: nodePos,
  472. };
  473. });
  474. const newEdges = edges.reduce(
  475. (acc, { target, targetHandle, source, sourceHandle }) => {
  476. const targetId = newIds.get(target);
  477. const sourceId = newIds.get(source);
  478. if (!targetId || !sourceId) return acc;
  479. acc.push({
  480. selected: true,
  481. target: targetId,
  482. source: sourceId,
  483. id: `edge-${nanoid()}`,
  484. targetHandle: targetHandle.replace(target, targetId),
  485. sourceHandle: sourceHandle.replace(source, sourceId),
  486. });
  487. return acc;
  488. },
  489. []
  490. );
  491. return {
  492. nodes: newNodes,
  493. edges: newEdges,
  494. };
  495. }
  496. function duplicateElements({ nodes, edges }) {
  497. editor.value.removeSelectedNodes(editor.value.getSelectedNodes.value);
  498. editor.value.removeSelectedEdges(editor.value.getSelectedEdges.value);
  499. const { edges: newEdges, nodes: newNodes } = copyElements(nodes, edges);
  500. editor.value.addNodes(newNodes);
  501. editor.value.addEdges(newEdges);
  502. }
  503. function copySelectedElements(data = {}) {
  504. store.copiedEls.nodes = data.nodes || editor.value.getSelectedNodes.value;
  505. store.copiedEls.edges = data.edges || editor.value.getSelectedEdges.value;
  506. }
  507. function pasteCopiedElements(position) {
  508. editor.value.removeSelectedNodes(editor.value.getSelectedNodes.value);
  509. editor.value.removeSelectedEdges(editor.value.getSelectedEdges.value);
  510. const { nodes, edges } = copyElements(
  511. store.copiedEls.nodes,
  512. store.copiedEls.edges,
  513. position
  514. );
  515. editor.value.addNodes(nodes);
  516. editor.value.addEdges(edges);
  517. }
  518. function onKeydown({ ctrlKey, metaKey, key }) {
  519. const command = (keyName) => (ctrlKey || metaKey) && keyName === key;
  520. if (command('c')) {
  521. copySelectedElements();
  522. } else if (command('v')) {
  523. pasteCopiedElements();
  524. }
  525. }
  526. /* eslint-disable consistent-return */
  527. onBeforeRouteLeave(() => {
  528. updateHostedWorkflow();
  529. if (!state.dataChanged) return;
  530. const confirm = window.confirm(t('message.notSaved'));
  531. if (!confirm) return false;
  532. });
  533. onMounted(() => {
  534. if (!workflow.value) {
  535. router.replace('/');
  536. return null;
  537. }
  538. state.showSidebar =
  539. JSON.parse(localStorage.getItem('workflow:sidebar')) ?? true;
  540. const convertedData = convertWorkflowData(workflow.value);
  541. updateWorkflow({ drawflow: convertedData.drawflow });
  542. state.workflowConverted = true;
  543. window.onbeforeunload = () => {
  544. updateHostedWorkflow();
  545. if (state.dataChanged) {
  546. return t('message.notSaved');
  547. }
  548. };
  549. window.addEventListener('keydown', onKeydown);
  550. });
  551. onBeforeUnmount(() => {
  552. window.onbeforeunload = null;
  553. window.removeEventListener('keydown', onKeydown);
  554. });
  555. </script>
  556. <style>
  557. .vue-flow,
  558. .editor-tab {
  559. width: 100%;
  560. height: 100%;
  561. }
  562. .vue-flow__node {
  563. @apply rounded-lg;
  564. }
  565. .dropable-area__node,
  566. .dropable-area__handle {
  567. @apply ring-4;
  568. }
  569. </style>